Skip to content

[orchestrator-v2] fix(orchestrator): Restore Claude session continuity for resume, wake, and idle release#3860

Open
mwolson wants to merge 2 commits into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:fix/claude-v2
Open

[orchestrator-v2] fix(orchestrator): Restore Claude session continuity for resume, wake, and idle release#3860
mwolson wants to merge 2 commits into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:fix/claude-v2

Conversation

@mwolson

@mwolson mwolson commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Mirrors the shape of the Grok v2 PR (#3578): one shared provider-continuation
plumbing commit (identical to the one carried by #3578) plus one Claude
specialty commit.

Four related failure modes around Claude provider session lifetime and
background work are fixed:

  • Idle-released sessions failed to reopen (Session ID ... is already in use);
    the adapter now resumes the persisted native session instead of recreating
    it.
  • Background tasks (run_in_background Bash) vanished when they settled; the
    CLI's post-settle wake turn is now ingested as a first-class continuation
    run, and idle release stays pinned while background work is pending.
  • Background subagents (Agent tool with run_in_background) lost their
    completion when it arrived after the root turn settled, and resuming a
    completed subagent via SendMessage was invisible: the row kept its stale
    first result forever. Both lifecycles now project correctly across turns
    and runs.
  • Releasing an idle session could deadlock silently on the SDK's suspended
    message read, orphaning the CLI child; the release path now unblocks the
    pending read and persists released events even if a finalizer wedges.

Branch shape

Problem and Fix

Problem and Why it Happened Fix
After ProviderSessionManager idle-releases a provider session (30 min default), the adapter's in-memory openedNativeThreads set is lost. The next turn's openQuery opens the native thread with {sessionId} (create semantics, --session-id) instead of {resume}, and the Claude CLI fails fast because the session id already exists on disk. In openQuery, treat a persisted provider turn as proof the native session exists: providerTurnOrdinal > 1 forces {resume} alongside the existing in-memory and rollback signals.
When a background task settles, the Claude CLI re-invokes itself and streams a whole new turn (task notification, assistant response, result) over the still-open SDK query. ClaudeAdapterV2.handleSdkMessage early-returned when activeTurn was null, so every message of the wake turn was dropped. Null-activeTurn wake evidence now buffers per native thread. A pending-task notification or result offers one continuation request (deduped per wake) to a new ProviderContinuationRequests queue; the ProviderContinuationService worker dispatches an internal message.dispatch (createdBy: "agent", creationSource: "provider", queue_after_active) so the wake flows through the normal run pipeline. The continuation turn sends no prompt to the CLI and drains the buffered wake messages into itself.
A background subagent's task_notification arriving after settle hit the same null-activeTurn drop, and even when replayed, the continuation turn's fresh per-turn maps could not find the subagent row created by the earlier turn. Subagent notifications for known running subagents count as wake evidence, and a session-scoped subagent registry (sessionSubagentsByTaskId) lets a wake-replay turn hydrate and terminalize a subagent created by an earlier, settled turn.
SendMessage to a completed subagent resumes it: the CLI re-emits task_started with the same task id but the SendMessage call's tool_use_id, then a second task_notification carries the new answer. Subagent status was monotone, so the re-open was ignored; the SendMessage tool_result (a delivery ACK) could also terminalize the re-opened row with ACK JSON as its "result". task_started for a known task id is an authoritative CLI lifecycle event and may re-open a terminal row (stale result and progress cleared); a late task_progress still cannot. Only a tool_result whose id is absent from toolCalls may terminalize a subagent, which excludes SendMessage ACKs (Agent launches never enter toolCalls; they project as subagent rows). Post-settle resume frames pre-open the session registry entry so idle stays pinned and the eventual notification counts as wake evidence.
Even after the re-open projected correctly at the adapter boundary, the events could vanish: RunExecutionService.routeProviderEvent only persists parent-thread subagent events whose runId matches the currently executing run, and a run's ingestion fiber only lingers past settle while it has active child subagents. A subagent that completed in-turn and was resumed later emitted events attributed to the launch run, whose fiber was already gone; they were silently dropped and the row froze on the stale result. A reopen re-attributes the subagent's runId to the run whose turn processes the resume. The continuation run's event filter then accepts the events, and its child-lifecycle tracking keeps its ingestion fiber alive until the resumed task completes. Parenting (parentNodeId/rootNodeId) stays with the launch run's root node.
The 30 minute idle release killed the CLI child (and any pending background task) before the wake could arrive. The adapter exposes an optional hasPendingBackgroundWork runtime capability; releaseIfStillIdle defers the idle_timeout release while it reports true, re-arming the timer, with cumulative deferral capped by maxIdlePinMs (default 4h).
Session scope finalizers run LIFO, so the message-pump fiber is interrupted before the closeSession finalizer. Interruption calls return() on the SDK's raw sdkMessages generator, which queues behind the in-flight idle read forever; the scope close never completes and query.close is never reached, orphaning the CLI child and leaving the projection stuck on "ready". claudeQueryMessages hands the stream an iterable whose [Symbol.asyncIterator]() returns the Query itself. The SDK's Query.return() runs cleanup() first, closing the transport and terminating the CLI child, so interruption completes and the remaining finalizers run.
The replay testkit typechecked only against claude-agent-sdk 0.3.170; 0.3.205 requires requestId on canUseTool options and allows a null PermissionResult. Raise the @anthropic-ai/claude-agent-sdk floor to ^0.3.205 (lockfile resolves 0.3.205), derive requestId from toolUseID for replayed permission requests, and fail the replay if canUseTool returns null. The native-stack patch file also gains its diff --git header so current aube can parse it.

Defensive Fixes

Problem and Why it Happened Fix
Any wedged session-scope finalizer parks releaseEntry forever with no released events and no log line. releaseEntry forks the scope close and joins with a 30s timeout. On timeout it logs provider-session-scope-close-timeout, attaches a detached observer for late completion or failure, and persists the released events regardless.
hasPendingBackgroundWork yields to the adapter between the idle check and the release, so a release could race a session that turned busy during the check. releaseEntry takes onlyIfIdleGeneration and revalidates busyCount/idleGeneration inside its atomic entry removal.
A live-stream fiber and a continuation drain can update the same subagent registry entry concurrently, so a resume re-open could clobber a newer terminal result installed between its lookup and its write. The session registry update is a CAS: a re-open only replaces the exact terminal generation its lookup resolved; otherwise the newer entry wins.

Validation

  • vp check: pass
  • pnpm install --frozen-lockfile: pass after the SDK bump to 0.3.205
  • vp run typecheck: pass (full monorepo)
  • vp test .../ClaudeAdapterV2.test.ts: 24/24, including background wake
    turns (buffer + single deduped continuation request; drain into a
    continuation turn with no CLI prompt; racing user turn leaving the buffer
    for the queued continuation; null-summary notification clearing the
    pending-task registry; spurious continuation settling immediately) and the
    subagent lifecycle (post-settle completion via the session registry;
    in-turn SendMessage resume with re-open, ACK guard, and run
    re-attribution; post-settle resume whose frames race past settle; late
    task_progress unable to re-open a terminal row)
  • vp test .../ProviderSessionManager.test.ts: 18/18 (idle release deferred
    while background work is pending, pinned session released once
    maxIdlePinMs expires, turn starting during the pending-work check never
    losing the session)
  • ClaudeReplayFixtures.integration.test.ts and
    OrchestratorReplayFixtures.integration.test.ts (claude fixtures): pass
  • Live scenario packs against a headless serve of this branch: claude-smoke
    (in-turn background bash, background subagent, monitor) and
    claude-continuation (post-settle bash wake, post-settle subagent
    completion, SendMessage resume of a completed subagent), all passing,
    covering both resume shapes (in-turn nudge with lingering launch-run
    ingestion, and CLI autonomous-turn nudge requiring the run
    re-attribution)
  • Manual AppImage verification of all six scenarios, including thread-data
    audits of run, subagent, and message projections in sqlite
  • Independently reviewed pre-fix by grok (composer-2.5) and codex (gpt-5.5);
    all Medium findings addressed. The subagent resume and re-attribution
    deltas were reviewed by grok-4.5 across four rounds; final verdict
    approve, no High or Medium findings

Known limitations

  • Background tasks do not survive a server restart: the CLI child dies with
    the server and the wake is lost (out of scope).
  • Wake traffic arriving while a normal turn is active ingests into that
    active turn, exactly as today; this PR only changes the previously-dropped
    null-activeTurn path.
  • If the pin cap expires with a task still running, the session releases as
    today and that wake is lost (same outcome as before, minus up to 4 hours).
  • A continuation dropped by the worker (archived thread, transient dispatch
    failure) is logged but not retried; the wake buffer then sits until a later
    turn drains it or the pin cap releases the session.
  • The 30s scope-close timeout is a diagnosable backstop, not a substitute for
    cleanup; provider-session-scope-close-timeout is the operational signal.
  • After a resume re-attributes a subagent to the resuming run, the UI's
    optional attempt-metadata link for that row can resolve to none (its
    runId moves while its parent node stays with the launch run). The
    timeline entry itself still renders.

Note

Restore Claude session continuity for resume, wake, and idle release in orchestrator-v2

  • Adds ProviderContinuationRequests, a context-level queue that the Claude adapter uses to signal background work completions, and ProviderContinuationService which drains that queue and dispatches internal messages to resume sessions.
  • Updates makeClaudeAdapterV2 to buffer SDK messages that arrive outside an active turn (wake messages), offer a single continuation request per wake, and drain buffered messages into agent-originated continuation turns instead of sending a new user prompt.
  • Adds hasPendingBackgroundWork to ProviderAdapterV2SessionRuntime, allowing ProviderSessionManagerV2 to defer idle release while background tasks, running subagents, or buffered wake messages exist, with a configurable cap (default 4 hours).
  • Fixes ProviderSessionManagerV2 to persist release and notify subscribers even when adapter scope close hangs (30s timeout), and to guard releases against stale idle-generation decisions.
  • Fixes the claudeQueryMessages iterator to call Query.return() on stream interruption, running cleanup and closing the transport to avoid deadlocks during idle interrupts.
  • Bumps @anthropic-ai/claude-agent-sdk from ^0.3.170 to ^0.3.205.
  • Risk: idle release is deferred (up to 4 hours by default) whenever an adapter reports pending background work; sessions will not be torn down until the cap expires or background work clears.

Macroscope summarized c489f02.

Introduce a provider-agnostic continuation request queue, worker, and optional
hasPendingBackgroundWork idle pin so adapters can surface post-settle native
wake traffic as first-class runs. No adapter offers continuations yet; behavior
is unchanged until Claude or Grok specialty commits wire attach mode.
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: febd05c2-bb1a-4875-8eaf-306a08bf9998

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

ordinal: projection.messages.length + 1,
});
const commandId = CommandId.make(`provider-continuation:${messageId}`);
yield* threads.dispatch({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium orchestration-v2/ProviderContinuationService.ts:42

When a provider continuation request is dispatched, the worker sends a message.dispatch using only request.threadId and ignores request.providerThreadId. The orchestrator resolves the target provider thread from the thread's current activeProviderThreadId, so if the active provider thread changed between when the wake was queued and when it is drained, the continuation run starts against the wrong provider thread. The buffered Claude wake messages pinned to the original native thread are never consumed by that run, so the background-task wake is silently lost until some later turn happens to target the original thread. Consider including request.providerThreadId (or the original native thread identifier) in the dispatch so the continuation is routed to the correct provider thread regardless of any subsequent thread switch.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ProviderContinuationService.ts around line 42:

When a provider continuation request is dispatched, the worker sends a `message.dispatch` using only `request.threadId` and ignores `request.providerThreadId`. The orchestrator resolves the target provider thread from the thread's *current* `activeProviderThreadId`, so if the active provider thread changed between when the wake was queued and when it is drained, the continuation run starts against the wrong provider thread. The buffered Claude wake messages pinned to the original native thread are never consumed by that run, so the background-task wake is silently lost until some later turn happens to target the original thread. Consider including `request.providerThreadId` (or the original native thread identifier) in the dispatch so the continuation is routed to the correct provider thread regardless of any subsequent thread switch.

@@ -3643,6 +3861,7 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function*
const idAllocator = yield* IdAllocatorV2;
const queryRunner = yield* ClaudeAgentSdkQueryRunner;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium Adapters/ClaudeAdapterV2.ts:3862

makeDefaultClaudeAdapterV2 yields ProviderContinuationRequests and passes it into the adapter, but the exported layer omits ProviderContinuationRequests from its requirement type. Since ProviderContinuationRequests is a Context.Reference, composing ClaudeAdapterV2.layer with ProviderContinuationRequests.layer resolves the default { offer: () => Effect.void } inside the adapter, silently dropping every continuation request. Claude background-task wake turns are never enqueued, breaking session continuity. Add ProviderContinuationRequests to the Layer.Layer requirement type so callers must supply (or inherit) the live queue.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts around line 3862:

`makeDefaultClaudeAdapterV2` yields `ProviderContinuationRequests` and passes it into the adapter, but the exported `layer` omits `ProviderContinuationRequests` from its requirement type. Since `ProviderContinuationRequests` is a `Context.Reference`, composing `ClaudeAdapterV2.layer` with `ProviderContinuationRequests.layer` resolves the default `{ offer: () => Effect.void }` inside the adapter, silently dropping every continuation request. Claude background-task wake turns are never enqueued, breaking session continuity. Add `ProviderContinuationRequests` to the `Layer.Layer` requirement type so callers must supply (or inherit) the live queue.

@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 11, 2026
@mwolson mwolson marked this pull request as ready for review July 11, 2026 04:38
providerSessionId: input.providerSessionId,
pinnedForMs: now - pinnedSinceMs,
});
yield* Ref.update(sessions, (latest) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium orchestration-v2/ProviderSessionManager.ts:622

The deferral update in releaseIfStillIdle does not revalidate idleGeneration, so if a turn starts and completes while hasPendingBackgroundWork is yielding, this stale callback overwrites the pinnedSinceMs reset done by markBusy with the old value and re-arms the timer. The pin cap is then measured across the intervening active turn, causing a background task from the new idle period to be released earlier than maxIdlePinMs. Add a check that latestEntry.idleGeneration === input.generation before applying the deferral update, matching the guard already used for the release branch.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/ProviderSessionManager.ts around line 622:

The deferral update in `releaseIfStillIdle` does not revalidate `idleGeneration`, so if a turn starts and completes while `hasPendingBackgroundWork` is yielding, this stale callback overwrites the `pinnedSinceMs` reset done by `markBusy` with the old value and re-arms the timer. The pin cap is then measured across the intervening active turn, causing a background task from the new idle period to be released earlier than `maxIdlePinMs`. Add a check that `latestEntry.idleGeneration === input.generation` before applying the deferral update, matching the guard already used for the release branch.

Comment thread apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/ProviderContinuationService.ts
@macroscopeapp

macroscopeapp Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

3 blocking correctness issues found. This PR introduces significant new runtime behavior for Claude session continuity, including background task tracking, wake turns, continuation requests, and modified idle release logic. Four unresolved review comments exist, including a high-severity issue about CAS reject still emitting reopen events.

You can customize Macroscope's approvability policy. Learn more.

…, and idle release

Resume persisted Claude sessions after provider session recycle. Surface
background wake turns as continuation runs: when a background task
(run_in_background Bash) settles, the Claude CLI streams a whole new turn over
the still-open SDK query; buffer null-activeTurn wake messages, request one
internal continuation run per wake, and drain the buffer in attach mode without
re-prompting the CLI, reporting pending background work so idle release stays
pinned until the wake is ingested. Prevent session release from hanging on
idle CLI reads.

Require claude-agent-sdk 0.3.205 and align the replay testkit with it:
derive requestId from toolUseID for replayed permission requests and fail
the replay when canUseTool returns null. Regenerate the native-stack patch
header so current aube parses it.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c489f02. Configure here.

return current;
}
return new Map(current).set(input.taskId, subagent);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CAS reject still emits reopen

High Severity

The session-registry CAS can refuse a resume reopen when a newer terminal entry won the race, but turn maps are already overwritten and subagent.updated / node.updated still emit the reopened running state with a cleared result. Projection can finish on a stale running row after the task actually completed, which is the clobber the CAS was meant to prevent.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c489f02. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant